Skip to content

fix!: table names stay readable in logs, Unity Catalog tag values as written, plan refuses a blocked residency - #868

Merged
fas89 merged 13 commits into
mainfrom
fix/databricks-redaction-tags-residency
Nov 13, 2026
Merged

fas89 merged 13 commits into
mainfrom
fix/databricks-redaction-tags-residency

Conversation

@fas89

@fas89 fas89 commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Three defects seen live on 12 Nov 2026 (data-product-forge 0.29.0, Databricks Free Edition), fixed in one PR because each stayed small. The PR has three feature commits, four from the review rounds, and one that keeps #861 working (it called the removed keep_dotted_names); it squash-merges to one.

What changed and why

1. Log redaction stopped masking fully qualified table names. fluid apply printed databricks.load table=***REDACTED*** rows=340 for northwind_towers.bronze_smart_meters.smart_meters, but printed the real name of the next table, whose last part had 11 characters. The cause was a prefix-agnostic JWT rule (_JWT_RE) that masked any three dotted runs of 12 or more characters, and a catalog.schema.table name has exactly that shape.

Now such a run is masked only when its first segment base64url-decodes to a JSON object, which is how a JOSE header looks. This is the check detect-secrets' JwtTokenDetector makes. The eyJ-anchored rules, which gitleaks and trufflehog also anchor on, still run first, and they now cover:

  • a JWE, including one with direct encryption (an empty encrypted-key segment);
  • an unsecured or detached JWS;
  • a header written with spaces or newlines;
  • a token glued to a word (token_eyJ…).

The global redactor and its Snowflake twin share one redact_jose_tokens(), so the two cannot drift. The rules now start where no base64url character precedes the match, instead of at \b. That ends the quadratic scans on a-a-a-… and eyJ-eyJ-…: 200,000 characters took 30 to 90 s before and now take under a second.

keep_dotted_names is removed, since logs and printed errors now apply the same rule.

2. Unity Catalog tag values are written as the contract states them. Before, a label value reached Unity Catalog as GCP's label slug: lowercased, with spaces and punctuation replaced by -, cut at 63 characters (business_value = 'a-single-unrenewed-ground-lease-can-strand-a-multi-tenant-tower', fluid_jurisdiction = 'us').

  • On Databricks, governance_labels(verbatim_values=True) keeps the contract's text, minus surrounding whitespace, which Unity Catalog refuses. Frameworks are joined as GDPR, CCPA.
  • A value over 256 characters is refused as databricks-tag-value-too-long, never cut.
  • Keys are unchanged: - still becomes _. GCP labels and AWS tags are unchanged.

What this means for an existing deployment: tag_value has no RequiresReplace at provider v1.140.0, and the provider's Update sends UpdateMask: "tag_value". The address and import id depend on the key alone. So the next apply updates each tag in place (~) and destroys nothing. The CHANGELOG says this.

3. fluid plan refuses a contract its own sovereignty policy blocks, by default. Founder's choice: gate plus skip flag.

fluid validate and plan --check-sovereignty refused Region 'us-west-2' is explicitly denied by sovereignty policy, yet plain fluid plan wrote a plan for it. Now plan runs the policy engine, the same check validate runs, before it plans:

  • A blocking verdict exits 1 (ERR_SOVEREIGNTY_VIOLATION) and writes no plan.
  • The findings are printed with their expose ids and carried in the error's context as errors and message. That is the shape local_plan_validation_failed has, so Command Center's plan endpoint surfaces the refusal. Without it, CC fell back to an ungated parse-only plan.
  • The check builds no provider.
  • --check-sovereignty runs the same check and then asks the provider's hook. A hook that reports nothing can no longer pass what validate refuses.
  • --no-check-sovereignty (BooleanOptionalAction) writes the plan anyway, says so on the console and logs a WARNING (sovereignty_check_skipped).

Apply is unchanged. On Databricks and GCP it already refuses such a placement at emit; verified on the real overlay.

Borrow before build

  • Checked locally: _JWT_ANCHORED_RE already existed (console only). _product_tags already wrote verbatim values and refused over 256. --no-verify-plan-binding and --allow-data-loss are the escape-hatch precedent. Command Center origin/main has no keep_dotted_names, and its label mirror covers AWS and GCP only.
  • Searched and fetched:
    • Databricks "Apply tags to Unity Catalog securable objects": keys and values up to 256 characters, no leading or trailing spaces.
    • terraform-provider-databricks v1.140.0, resource_entity_tag_assignment.go: tag_value is updated in place.
    • gitleaks jwt rule (ey…), detect-secrets JwtTokenDetector (eyJ plus JSON decode), trufflehog jwt.go (eyJ|ewogIC|ewoid).
    • HCP Terraform policy enforcement levels: mandatory policies stop the run, and overrides are explicit.
    • checkov --soft-fail.
  • Strategy: adapt the pattern, cited in the code and commit messages.

Validation

  • CI lint battery reproduced: ruff, the dead-code gate, black==24.10.0, product guardrail, license headers, lint-imports (6 kept), detect-secrets==1.5.0 on changed files, strict mypy on the hotspots (secret_redactor.py included), and the agent-skill reference --check. All clean.
  • Full suite, CI-style (tofu hidden from PATH, --randomly-seed=20261112, -n 6): 35,101 passed on the final rebased head. Every failure is the documented shared-venv baseline (lazy core commands ×2, dotenv dlt ×4, duckdb sandbox workspace root ×2, the MCP Snowflake-login flake, cycles budget e11 under load, and the dbt-prefix PATH test, which fails on pristine origin/main too) plus 2 bashlex collection errors. tests/fragments/test_compat_gate.py, run alone as the shared venv needs: 45 passed.
  • The fake-workspace e2e with real tofu and provider 1.140.0 goes from slug tag values to verbatim: +0 ~2 -0, nothing replaced.
  • Real input, dp007 (read-only copy):
    • --env region-only plan: exit 1, no plan written.
    • --env databricks (declared override): exit 0.
    • --no-check-sovereignty: exit 0, with a warning.
    • apply of the skipped plan: SovereigntyViolationError.
    • The emitter's tags: business_value, grain and standards verbatim, fluid_jurisdiction = 'US'.
  • Live on Free Edition, scratch catalog fluid_iactest_small, created and then dropped with CASCADE; workspace and northwind_towers untouched:
    • Apply printed databricks.load table=fluid_iactest_small.bronze_smart_meters.smart_meters rows=3.
    • A product tagged with 0.29.0-style slugs planned +0 ~17 -0 under this code, then applied.
    • information_schema.table_tags shows the values verbatim: the 256-character boundary, ;, ,, :, /, =, ( ), Zürich. The rows were untouched.
    • fluid verify passed on all 27 tags, and a second dry-run gave +0 ~0 -0.
  • ripwire --quality-delta: the real regressions are fixed (governance_labels complexity 30→31, _uc_tags length, test duplication). It still exits 2 on dead-code name-resolution artifacts in untouched files (was=0, now=0), churn and 27–37-token coincidental clones.

Reviews

  • /security-review: no HIGH or MEDIUM. The Opus judge on the review-round diff: no HIGH/MEDIUM in the fix diff or the whole branch, after probing about 37 credential shapes against origin/main in both layers. Its two below-threshold residuals (a token glued after a dotted name, a glued header written with spaces) are fixed in 9496aac and re-checked: no issue.

  • /world-class-inspection (Diff): CONCERNS, with one P2: eyJ-eyJ-… was still quadratic through the two-segment rule, and the test missed it. That P2 and the P3s are fixed in 76e9c153 and re-verified.

  • /defect-scan (Diff): no Critical, High or Medium in the code this change touches. Three LOW issues are fixed in 776bf09f:

    • a non-string label spelled as Python under generate iac;
    • a whitespace-header direct-encryption JWE that went unmasked;
    • expose ids lost to Rich markup.

    The Command Center fallback is closed from forge's side.

  • Re-check round: one auditor confirmed all 9 fixed findings with 316 tests and a 150,000-name fuzz that masked no name; every entry point stays under 1 s on 200,000-character input; no new Critical/High/Medium.

Integration with the PRs that merged meanwhile

Documentation

  • Docs PR linked:
  • No docs needed
  • Docs TODO — I know docs are needed and will create a PR in forge_docs before this is merged
    • Tracked on the board as P1 (founder, 2026-11-13): https://trello.com/c/vVqBSFqk
    • docs/cli/plan.md: plan checks sovereignty by default and refuses what fluid validate refuses (ERR_SOVEREIGNTY_VIOLATION, no plan written); --check-sovereignty adds the provider hook; --no-check-sovereignty skips with a warning.
    • The Databricks provider page: Unity Catalog tag values are written as the contract states them (at most 256 characters, else databricks-tag-value-too-long); an existing deployment's tags update in place on the next apply.
    • upgrading.md: both behaviour changes, under the not-yet-released section.

Follow-ups (pre-existing, not in this PR)

  • Two label keys that coerce to the same GCP key (Foo Bar and foo-bar) silently overwrite each other in governance_labels. This affects GCP, AWS and Databricks and is identical on main.
  • The stand-in Databricks server's listen(5) backlog overflows under tofu's 10-way refresh on macOS, which sends an RST, so local fake e2e runs sometimes fail with write: broken pipe. Linux CI drops the SYNs and retries instead.
  • Whether AWS's OpenTofu apply enforces sovereignty is not established here. The CHANGELOG claims only Databricks and GCP.

…able name

fluid apply printed "databricks.load table=***REDACTED*** rows=340" for
northwind_towers.bronze_smart_meters.smart_meters and the real name for a
table whose last part had 11 characters. The prefix-agnostic _JWT_RE masked
any three dotted runs of 12 or more characters, and a catalog.schema.table
name (or project.dataset.table, DB.SCHEMA.TABLE) has that shape.

Such a run is now masked only when its first part is a JOSE header: base64url
of a JSON object, the check detect-secrets' JwtTokenDetector.is_formally_valid
makes (Yelp/detect-secrets plugins/jwt.py). The eyJ-anchored rule, which
gitleaks' jwt rule and trufflehog's JWT detector also anchor on, now covers a
JWE (a direct-encryption one has an empty encrypted key) and short or empty
payloads and signatures. One redact_jose_tokens() serves both redaction
layers: the Snowflake twin calls it, as it imports _URL_USERINFO_RE, so they
cannot drift. The rules start where no base64url character precedes them
instead of at \b, which matched after every '-' and made the scan quadratic
on a-a-a-... input (200k characters: about 30 s before, 0.07 s now).

keep_dotted_names goes: logs and printed errors apply the same rule now.
…s them

The governance labels became Unity Catalog tags with GCP's label slugs:
lowercased, spaces and punctuation as '-', cut at 63 characters
(business_value = 'a-single-unrenewed-ground-lease-can-strand-a-multi-tenant-
tower', fluid_jurisdiction = 'us'). A UC tag value takes up to 256 characters
with case, spaces and punctuation, and may not start or end with a space
(docs.databricks.com, "Apply tags to Unity Catalog securable objects").

governance_labels() gains verbatim_values: the Databricks module asks for the
contract's text, stripped, with the regulatory frameworks joined by ", ", and
refuses a value over 256 as databricks-tag-value-too-long (one _too_long
refusal now serves the product tags too). Keys keep GCP's label alphabet,
each '-' an '_'; GCP labels and AWS tags are unchanged.

An existing deployment updates in place: databricks_entity_tag_assignment's
tag_value carries no RequiresReplace at provider v1.140.0 and Update sends
UpdateMask "tag_value" (entity_tag_assignment/resource_entity_tag_assignment.go),
and the address and import id depend on the key alone. Pinned by a fake-
workspace e2e (slug values, then the real ones: +0 ~2 -0, nothing replaced)
and observed on Free Edition: +0 ~17 -0, the table's rows untouched, verify
green on all 27 tags.
…default

fluid validate and fluid plan --check-sovereignty refused a contract whose
binding region its strict sovereignty denies ("Region 'us-west-2' is
explicitly denied by sovereignty policy"), but plain fluid plan wrote a plan
for it, and --check-sovereignty itself wrote the plan before it exited 1.

plan now runs the built-in policy engine, the checker validate runs, before
it plans, and refuses (exit 1, sovereignty_violation, no plan.json) whenever
validate would: strict findings, or a region in deniedRegions, which blocks in
every mode. It builds no provider, so a plain plan stays off the cloud SDKs
(Command Center's plan endpoint relies on that) and prints nothing when
nothing blocks. --check-sovereignty keeps asking the provider's hook and
printing the verdict, now before the write. --no-check-sovereignty
(argparse.BooleanOptionalAction) skips the check and logs a WARNING with the
event sovereignty_check_skipped, the shape of apply's --no-verify-plan-binding;
it changes nothing in apply, which still refuses the placement on Databricks.
The design follows HCP Terraform's policy enforcement levels: a mandatory
policy stops the run, and an override is explicit and recorded.

BREAKING CHANGE: fluid plan exits 1 and writes no plan for a contract its
sovereignty policy blocks; pass --no-check-sovereignty to plan it anyway.
…nty never weaker

From the branch's own review.

- _JWT_TWO_SEGMENT_RE still started at \b, so eyJ-eyJ-... stayed quadratic
  (200k characters: 9.75 s through redact_secret_text, 0.30 s now), and the
  linear test fed eyJ_ (no \b after '_'), so it could not see it. The rule now
  starts where _JWT_ANCHORED_RE does; _JWT_RE takes two runs as well as three,
  so a two-segment token glued after '-' is still masked by its header. The
  test feeds eyJ- too.
- _JWT_ANCHORED_RE took up to two more segments greedily, so "got <jwt>.Next"
  lost "Next". It now takes a JWS (three segments) or a JWE (five).
- --check-sovereignty asked the provider's hook first, so a hook that reported
  nothing passed a contract the new default refuses. It now runs the default
  gate first. --no-check-sovereignty also says so on the console.
- The changelog no longer says a failing plan stage leaves no plan file: one
  an earlier run left at --out stays.
- fluid plan's sovereignty refusal printed its findings through Rich markup,
  which took each finding's "[expose_id]" for a tag and dropped it; findings
  print literally now (here and in --check-sovereignty's report). The refusal
  also carries the findings as the CLIError's context errors and message, the
  shape local_plan_validation_failed has: Command Center's plan endpoint
  surfaces an engine refusal that has them, and fell back to an ungated
  parse-only plan for one that had not.
- _JWT_RE takes up to five segments, empty ones included, so a JWE with
  direct encryption whose header is written with a space or a newline is
  masked whole again (origin/main masked its last three segments; the branch
  left it). Its header check still decides, so names stay readable.
- governance_labels' verbatim values spell a YAML true/false as written and
  make no tag for null: fluid generate iac emits without the schema gate,
  which allows only strings, and wrote Python's "True".
From the security judge's re-check. A token glued after a dotted name
(northwind_towers.x-<header>.<payload>) and a header written with spaces or
newlines glued to a word (token_eyAi..., x-ewog...) were masked on origin/main,
whose rule masked any three 12+ runs, but not on the branch, which looked for
a header only in the run's first segment and only at "eyJ".

_mask_if_jose now checks every segment of the run and masks from the first
that holds a header, keeping the name before it. A glued header starts at an
'e' after '-' or '_' or at an "eyJ" (base64url of '{' always starts with 'e'),
six tries at most per segment. _JWT_RE takes the whole dotted run, and
_is_jose_header requires the "alg" member RFC 7515 and RFC 7516 (4.1.1) make
mandatory, so a short JSON part of a name such as e30 ({}) is no header.
200,000-character adversarial runs stay under 0.5 s.
… default

#861's clean() asked redact_secret_text for keep_dotted_names=True, the
keyword this branch removes: every log and printed error now keeps a dotted
name and masks a JWT by its header, so the call passes nothing extra. Without
this, every Databricks job-run message raised TypeError (39 tests in
tests/build_runners/test_databricks_job_run.py after rebasing onto #861).
@github-actions github-actions Bot added provider Changes or requests related to providers security Security-related changes or reports cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Nov 12, 2026
@github-actions

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

Comment thread fluid_build/_error_catalog.py Fixed
Comment thread fluid_build/_error_catalog.py Fixed
@github-actions github-actions Bot removed the needs-docs Pull request needs a linked docs update or justification label Nov 12, 2026
…nation in the catalog

From the code-quality review of #868. The two sovereignty_violation suggestions
split over adjacent literals now use the file's own parenthesised "+" form, and
the tests import fluid_build.observability.secret_redactor, the Snowflake logging
module and fluid_build.cli.plan one way each. No behaviour change; the baseline
moves line numbers only.
…o fix/databricks-redaction-tags-residency

# Conflicts:
#	.secrets.baseline
#	CHANGELOG.md
Test (Python 3.14) failed test_the_words_after_a_token_are_kept with
"got ***REDACTED******REDACTED***.Next step". The exact-match registry is
process-wide, and a masking test in the same xdist worker had registered the
base64url of bytes(range(32)), which is this test's signature, so layer 1
masked it first and the JOSE rule then masked "header.payload." on its own.
The token was masked whole either way; the exact-string expectation was not.

Both JOSE test classes now set the registry aside for each test and restore it
after (forget_known_secrets / register_secrets). Reproduced with that key
registered first: the old file fails 1, the new one passes 55.
…o fix/databricks-redaction-tags-residency

# Conflicts:
#	.secrets.baseline
#	CHANGELOG.md
@fas89
fas89 merged commit 594304c into main Nov 13, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation provider Changes or requests related to providers security Security-related changes or reports tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant